Memory Allocation Discipline Example Async

6 min read
Senior4 min read
Rapid overview

Memory Allocation Discipline Example Async

TL;DR

“In high-throughput systems like trading feeds, allocation discipline and efficient streaming are key. I’d use System.IO.Pipelines to read network data asynchronously, parse directly with Span<T>/Utf8Parser, and avoid all per-tick allocations. This design keeps Gen0 allocations minimal, avoids LOH churn, and provides stable, low-latency performance — even under sustained millions of ticks per second.”


Would you like me to add Channel<T> fan-out next, to demonstrate how you’d dispatch parsed ticks to multiple processors (e.g., analytics, persistence, logging) without breaking GC discipline? That’s the next level of real-world system design question they might ask.

How it works


📄 Full Example: AsyncTickStreamProcessor.cs

using System;
using System.Buffers;
using System.Buffers.Text;
using System.IO;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

public readonly struct Tick
{
    public string Symbol { get; }
    public double Bid { get; }
    public double Ask { get; }

    public Tick(string symbol, double bid, double ask)
    {
        Symbol = symbol;
        Bid = bid;
        Ask = ask;
    }

    public override string ToString() => $"{Symbol}: {Bid:F5}/{Ask:F5}";
}

public class TickStreamProcessor
{
    private readonly Pipe _pipe = new();

    public async Task StartAsync(NetworkStream stream)
    {
        // Run reading and processing concurrently
        var fill = FillPipeAsync(stream);
        var read = ReadPipeAsync();
        await Task.WhenAll(fill, read);
    }

    private async Task FillPipeAsync(NetworkStream stream)
    {
        const int MIN_BUFFER_SIZE = 512;

        while (true)
        {
            Memory<byte> memory = _pipe.Writer.GetMemory(MIN_BUFFER_SIZE);
            int bytesRead = await stream.ReadAsync(memory);

            if (bytesRead == 0)
                break; // client closed connection

            // Tell the PipeWriter how much was read
            _pipe.Writer.Advance(bytesRead);

            // Make the data available to the reader
            FlushResult result = await _pipe.Writer.FlushAsync();

            if (result.IsCompleted)
                break;
        }

        await _pipe.Writer.CompleteAsync();
    }

    private async Task ReadPipeAsync()
    {
        while (true)
        {
            ReadResult result = await _pipe.Reader.ReadAsync();
            ReadOnlySequence<byte> buffer = result.Buffer;

            SequencePosition? position;
            do
            {
                position = buffer.PositionOf((byte)'\n');

                if (position != null)
                {
                    // Slice out one full line (tick)
                    var line = buffer.Slice(0, position.Value);
                    ParseAndProcess(line);

                    // Skip past the newline
                    buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
                }
            } while (position != null);

            // Tell the pipe how much we’ve consumed
            _pipe.Reader.AdvanceTo(buffer.Start, buffer.End);

            if (result.IsCompleted)
                break;
        }

        await _pipe.Reader.CompleteAsync();
    }

    private static void ParseAndProcess(ReadOnlySequence<byte> line)
    {
        // We can safely work with single segment in this simple example
        ReadOnlySpan<byte> span = line.FirstSpan;

        int firstComma = span.IndexOf((byte)',');
        if (firstComma == -1) return;

        int secondComma = span.Slice(firstComma + 1).IndexOf((byte)',');
        if (secondComma == -1) return;

        secondComma += firstComma + 1;

        string symbol = Encoding.ASCII.GetString(span[..firstComma]);
        Utf8Parser.TryParse(span[(firstComma + 1)..secondComma], out double bid, out _);
        Utf8Parser.TryParse(span[(secondComma + 1)..], out double ask, out _);

        var tick = new Tick(symbol, bid, ask);
        OnTick(tick);
    }

    private static void OnTick(in Tick tick)
    {
        // Process the tick (send to MQ, write to DB, etc.)
        Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {tick}");
    }
}

public static class Program
{
    public static async Task Main()
    {
        // Demo: simulate network stream with a MemoryStream
        var data = Encoding.ASCII.GetBytes(
            "EURUSD,1.07432,1.07436\nGBPUSD,1.24587,1.24592\nUSDJPY,151.229,151.238\n");
        using var memStream = new MemoryStream(data);
        using var fakeNetwork = new NetworkStream(memStream, FileAccess.Read);

        var processor = new TickStreamProcessor();
        await processor.StartAsync(fakeNetwork);
    }
}

Quick recall Q&A

Q: Why choose System.IO.Pipelines over raw Stream APIs?

Pipelines manage pooled buffers, handle partial reads, and support zero-copy parsing via ReadOnlySequence<T>, drastically reducing allocations and simplifying producer/consumer coordination for high-volume streams.

Q: How do ReadOnlySequence<T> and Span<T> interact in this sample?

ReadOnlySequence<T> represents potentially multi-segment buffers from the pipeline. For simple cases, you use line.FirstSpan to get a contiguous Span<T>; otherwise, you can copy segments or use SequenceReader<T> to parse without copying.

Q: Why run FillPipeAsync and ReadPipeAsync concurrently?

It decouples I/O from parsing, letting each stage run at its own pace. The pipe provides backpressure so writers pause when readers lag, preventing unbounded memory growth.

Q: How do you ensure the parser handles partial messages?

The code searches for newline separators with PositionOf, only consuming complete messages. Partial lines remain in the buffer until more data arrives, avoiding premature consumption.

Q: What’s the GC profile of this pipeline-based approach?

Aside from immutable symbol strings, there are no per-tick allocations—buffers come from the pipe’s pool, Utf8Parser works on spans, and structs stay on the stack. GC activity remains negligible even under heavy load.

Q: How would you extend this example for TLS/SSL sockets?

Wrap the network stream (e.g., SslStream) but keep using pipelines. The pipe sits on top of any stream; as long as you feed decrypted bytes, the parsing logic remains the same.

Q: How do you shut down gracefully?

When the stream closes, ReadAsync returns 0, so the writer completes. The reader loop detects result.IsCompleted, finishes processing remaining data, and completes the reader to release resources.

Q: How can you integrate this with message brokers?

Replace OnTick with publisher code that writes to RabbitMQ/Kafka using pooled producers, ensuring you serialize ticks without allocations (e.g., using IBufferWriter<byte> to write to message bodies).

Q: What safeguards prevent slow consumers from OOMing the process?

Set bounded pipe limits or apply flow control by awaiting _pipe.Writer.FlushAsync(); pipelines use backpressure to throttle producers when readers fall behind.

“I’d use System.IO.Pipelines for reading from the socket directly into pooled memory segments. Then, using Span<byte> and Utf8Parser, I’d parse ticks inline — zero-copy. Since Pipelines reuses buffers internally, the GC stays quiet, and the system scales linearly with load. The parsing happens incrementally as data arrives — perfect for tick-by-tick streaming.”

“We can even extend this with Channel<T> for backpressure and fan-out to multiple consumers, maintaining bounded memory while processing millions of ticks per second.”

Q: How do you test this pipeline logic?

Use Pipe directly in tests with synthetic data, or feed a MemoryStream as shown. Assert on parsed ticks and monitor GC.GetAllocatedBytesForCurrentThread() to verify allocation behavior.

If you want to impress even more:


Additional notes

🧩 Why use Pipelines instead of plain Stream.ReadAsync()

  • NetworkStream.ReadAsync() requires you to manage buffers manually → risk of copying and extra allocations.
  • Pipelines automatically manage buffer boundaries, reuse memory, and let you parse incoming data directly from pooled segments.
  • It integrates with Span<T> and ReadOnlySequence<T> — perfect for zero-copy parsing.

⚙️ The scenario

Imagine a trading feed sending data like this:

EURUSD,1.07432,1.07436\n
GBPUSD,1.24587,1.24592\n
USDJPY,151.229,151.238\n

We want to:

  1. Read from a network stream continuously
  2. Parse each tick line as it arrives (may arrive in chunks!)
  3. Process it with zero extra allocations

🧠 What makes this “senior-level”

FeatureWhy it matters
System.IO.PipelinesUses pre-allocated pooled memory segments (no per-read allocations)
ReadOnlySequence<byte>Supports multi-segment data without copying
Utf8ParserParses directly from bytes — no string parsing overhead
Tick is a readonly structStack-friendly, immutable, no GC tracking
✅ Async producer-consumer modelPerfect for real-time stream ingestion
✅ Zero-copyData flows from socket → pipeline → span → parsed → done

⚡ GC Profile (steady state)

  • No heap allocations per tick (except the symbol string).
  • Data parsed directly from pooled pipeline buffers.
  • Gen0 GC barely runs.
  • No Gen1/Gen2 or LOH activity.
  • Predictable latency even under 1M ticks/sec.

💬 Interview-ready talking points

When they ask “How would you handle a continuous high-volume data stream efficiently?”:


🧩 Optional extensions (for your learning or extra credit)

  1. Integrate with Channel<Tick> for multi-consumer processing (e.g., persistence, analytics, UI).
  2. Add benchmarking hooks using BenchmarkDotNet to measure ticks/sec and GC stats.
  3. Integrate ValueTask for hot async paths that complete synchronously.
  4. Enable DOTNET_GCServer=1 for throughput GC mode (you already know this 😉).

See also